Skip to content

assignments_to_experiments: convert values that ramp with time, via a rate rule - #557

Merged
FFroehlich merged 4 commits into
mainfrom
claude/assignments-to-experiments-ramps
Oct 25, 2026
Merged

FFroehlich merged 4 commits into
mainfrom
claude/assignments-to-experiments-ramps

Conversation

@FFroehlich

@FFroehlich FFroehlich commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #556 (merge that first).

assignments_to_experiments rejects an assignment whose value changes with time within a period, because a condition change is applied once at the start of its period, with time set to the period's start: on a constant target it can only step. Fujita_SciSignal2010's EGF input is such an assignment,

EGF := piecewise(EGF_rate * time + EGF_0, time <= EGF_end, 0)

steps in the training data and ramps (EGF_0 = 0, EGF_rate > 0) in its held-out data.

What. A value may now change linearly with time (its rate of change must not itself depend on time; time^2, exp(time) are still rejected). The only change to the model is to make the target a differential entity, as the v2 condition table allows targets to be constant or differential entities: d target/dt = <target>_slope, with a new parameter for the slope, a placeholder at 0. Everything else goes through the condition and experiment tables: every period that starts a piece, including the first at time 0, sets both the target's value at the period start and its slope:

Fujita condition t EGF EGF_slope
pulse 0 EGF_0 0
pulse EGF_end 0 0
ramp 0 0 EGF_rate
ramp EGF_end 0 0

The slope parameter stands in for a setRate operation, which earlier v2 drafts had (PEtab-dev/PEtab#581) and which was dropped before release. If it returns, the rate rule becomes d target/dt = 0 and the slopes become setRate rows.

Changes at the start of an experiment may only refer to parameter table symbols, so constant model parameters in them take their model values.

During preequilibration the value is held at its value at time 0 with slope zero (consistent with the existing preequilibration semantics and warning), and the first period after preequilibration sets the slope again. An assignment that only switches between constant values converts exactly as before, as a constant parameter; the rate rule is only introduced where a piece actually ramps.

Checked

  • Tests: a ramp ending at a switch, Fujita-style pulses and ramps set by the condition table, a ramp after preequilibration; time^2 and exp(time) values rejected.

  • Fujita, PEtab v1 against the v2 upgrade converted by this branch, AMICI at the nominal parameters:

    problem v1 llh converted v2 llh (atol 1e-10 / 1e-14)
    training (6 step doses) 53.0829 53.0833 / 53.0838
    test data: 6 pulses −4028.1483 −4028.1482 / −4028.1483
    test data: 4 ramps −1085.2180 −1085.2180 / −1085.2180
    test data: all 10 −5060.2833 −5060.2829 / −5060.2825

    (The test-data problems were loaded without their visualization table, which they share with the training problem and which names datasets their measurement tables lack; the lint rejects them otherwise.)

  • Brannmark_JBC2010, Isensee_JCB2018 and Raimundez_PCB2020 convert with log-likelihoods identical to assignments_to_experiments: convert assignments that switch more than once #556 (Raimundez with ExperimentsToSbmlConverter: write one flat initial assignment per target #546 merged in, which it needs to import at all).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MNJePorYs9zmPYMam6g4EP

@codecov-commenter

codecov-commenter commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.59259% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.91%. Comparing base (68be6d1) to head (c52a56f).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
petab/v2/converters.py 92.59% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #557      +/-   ##
==========================================
+ Coverage   78.85%   78.91%   +0.06%     
==========================================
  Files          69       69              
  Lines        8043     8071      +28     
  Branches     1445     1449       +4     
==========================================
+ Hits         6342     6369      +27     
- Misses       1214     1215       +1     
  Partials      487      487              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@FFroehlich
FFroehlich added this pull request to stack #558 October 16, 2026 09:19
@FFroehlich
FFroehlich marked this pull request as ready for review October 16, 2026 09:19
@FFroehlich
FFroehlich requested a review from a team as a code owner October 16, 2026 09:19
@FFroehlich
FFroehlich force-pushed the claude/assignments-to-experiments-ramps branch from a9279e1 to d8b0bbf Compare October 19, 2026 08:29
@FFroehlich

Copy link
Copy Markdown
Collaborator Author

see also discussion in #556

Base automatically changed from claude/assignments-to-experiments-several-switches to main October 19, 2026 13:29
@FFroehlich
FFroehlich force-pushed the claude/assignments-to-experiments-ramps branch from d8b0bbf to e960510 Compare October 19, 2026 13:30

@dweindl dweindl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.

Since that function is getting longer and longer, how about converting that into a class and moving some parts to some private helpers (find available ID, add parameter, add RateRule, ...) and encapsulating the currently free non-reusable helpers like _changes? So far, this is unreleased and wouldn't really break much. (Independent of this PR to keep the diff more readable.)

Comment thread petab/v2/converters.py Outdated
@dweindl

dweindl commented Oct 19, 2026

Copy link
Copy Markdown
Member

On second thought, turning that into a class is probably not really helpful, because it's just one operation with one result. Slimming down that function would still be good.

Comment thread petab/v2/converters.py
@FFroehlich
FFroehlich force-pushed the claude/assignments-to-experiments-ramps branch from 422e254 to 304dfe7 Compare October 25, 2026 12:52
FFroehlich and others added 4 commits October 25, 2026 13:09
… rate rule

A condition change is applied once at the start of its period, with `time` set to the period's
start, so a value that changes with time within a period cannot be a condition change on a
constant target, and such assignments were rejected. Fujita_SciSignal2010's EGF input is one:
`piecewise(EGF_rate * time + EGF_0, time <= EGF_end, 0)`, steps in training and ramps in its
held-out data.

A value may now change linearly with time. The target then becomes a differential entity, with
the rate rule `d target/dt = <target>_slope` and a new constant parameter for the slope, and
each period sets both the target's value at the period start and its slope. During
preequilibration the value is held at its value at time 0 with slope zero, and the first period
after preequilibration sets the slope again. An assignment that only switches between constant
values converts exactly as before, as a constant parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNJePorYs9zmPYMam6g4EP
…ifferential

The slope parameter is a placeholder at 0; every ramp's start value and
slope, including at time 0, are set through the condition table. Constant
model parameters in changes at an experiment's start take their model
values, since those changes may only refer to parameter table symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MNJePorYs9zmPYMam6g4EP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`add_sbml_parameter` checks libsbml's return codes, which the hand-written
parameter creation did not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FFroehlich
FFroehlich force-pushed the claude/assignments-to-experiments-ramps branch from 54328e6 to c52a56f Compare October 25, 2026 13:19
@FFroehlich
FFroehlich merged commit 0e1cef3 into main Oct 25, 2026
13 checks passed
@FFroehlich
FFroehlich deleted the claude/assignments-to-experiments-ramps branch October 25, 2026 13:26
FFroehlich added a commit that referenced this pull request Oct 25, 2026
#557 was rebased onto main (with the pandas fix #571), so #559 conflicted
with its base. The result is the same tree as #559 rebased onto #557.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FFroehlich added a commit that referenced this pull request Oct 25, 2026
#557 was squash-merged into main, so #559 conflicted with main. The
result is the same tree as before, since main now has exactly #557's
content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FFroehlich added a commit that referenced this pull request Oct 25, 2026
…#559)

A v2 period's `time` overrides the model's initial time, so an
experiment starts at its first period after pre-equilibration.
`assignments_to_experiments` dropped that period whenever nothing
changed there. Either it was redundant, because the model already holds
the value before the switch, or its condition only set symbols that the
conversion replaced, so the condition was emptied and removed. The
experiment then started at its first switch, and measurements before the
switch were simulated from the wrong start.

For example, `k1 := piecewise(1, time < 5, 2)` converted to
`default_experiment` with a single period at t = 5. The existing
`test_assignments_to_experiments` asserted that shape: one period at t =
10, beside a measurement at t = 0.

## Changes

- Every converted experiment keeps the period that starts it, without
conditions if none are left. For `default_experiment`, and for an
experiment that only pre-equilibrates, that is the period at t = 0 the
converter already adds. The `or experiment.periods[:1]` fallback goes,
since the start period is never dropped.
- A switch therefore never becomes an experiment's first period, and
`CheckInitialChangeSymbols` only checks the first period. A start value
that equals the model's value is no longer set just because a switch
refers to symbols outside the parameter table. For `level * piecewise(0,
time < 10, 1)`, the experiment now has an empty period at 0 and `k1 =
level` at 10, where it had `k1 = 0` at 0 and `k1 = level` at 10. A start
value that refers to parameter table symbols is still set at the start
(as since #556), since the model only holds a number for it.

## Tests

- `test_assignments_to_experiments`, `_nested`, `_condition_specific`,
`_several_switches` and `_fixed_switching_time` now expect the empty
period at t = 0.
- New `test_assignments_to_experiments_simulate_before_switch` covers
both cases: no experiment, and a condition at t = 0 that only sets the
switching time. It converts the problem, turns both the original and the
converted problem into SBML events with `ExperimentsToSbmlConverter`,
simulates them in BasiCO at t = 0, 1, 2, 4, 6 and 10, and compares the
results. They match to 3e-11 relative (the test asserts 1e-6). Without
this fix, the converted problem is off by up to 45% before the switch.
- The benchmark problems that convert (Brannmark_JBC2010,
Fujita_SciSignal2010, Isensee_JCB2018, Raimundez_PCB2020) give the same
experiment table, condition table and SBML with and without this change.

## Relation to #557

Stacked on #557; #556 is merged. The bug has been on main since #527. On
main the change merges cleanly as text, but main's
`test_assignments_to_experiments_several_switches` and
`_fixed_switching_time` assert the old shape, with no period at t = 0,
and #557 reworks the `redundant` block that this change simplifies. This
PR's base moves to main once #557 merges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabian Fröhlich <frohlif@login006.nemo.thecrick.org>
FFroehlich added a commit that referenced this pull request Oct 25, 2026
Follows #559. A refactor of `assignments_to_experiments` that does not
change its behaviour. The fixes for bugs found while reviewing and
stress-testing it are in #574, stacked on this PR.

## Slimming down `assignments_to_experiments`

The function had grown to 340 lines, most of them one loop body passing
around the parsed assignment, its switches, its value before every
switch, the switching times' fixed values and the slope parameter of a
ramp. It is now about 75 lines (plus its docstring) that run the steps
in order:

- `_add_start_periods`: give every experiment the period that starts it,
and find the periods that may be dropped;
- for each time-dependent assignment rule, a `_PiecewiseAssignment`:
- its constructor checks that the assignment is supported and works out
the value before every switch, the switching times' fixed values and
whether the value ramps;
- `replace_in_model()` replaces the rule in the model (the slope
parameter and rate rule for a ramp, the target's value);
- `changes(experiment)` returns the condition changes that replace it in
an experiment. The old module-level `_changes` and `_value_from` became
its methods.
- `_drop_changes` / `_add_conditions`: replace the condition changes the
new conditions cover;
- drop empty periods, add the default experiment, lint.

The `redundant` / `after_preequilibration` double negative became one
decision per period: one branch for the first period, one for the later
ones.

On the review comment on #557 about not turning this into a class:
`assignments_to_experiments` stays a function. `_PiecewiseAssignment`
only holds the values of one assignment that the old loop body passed
between its parts. If you would rather not have it, it can become a few
free functions that take those values.

No change in behaviour: on all 40 conversions in the tests, the old and
new implementations give the same experiment table, condition table,
measurement table, SBML, errors and warnings. The benchmark problems
Brannmark_JBC2010, Fujita_SciSignal2010, Isensee_JCB2018,
Raimundez_PCB2020 (and Oliveira_NatCommun2021's error) also convert
identically.

## Checked

- `pytest tests/v2 tests/v1/test_petab.py`: 244 passed; pre-commit
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fabian Fröhlich <frohlif@login006.nemo.thecrick.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants